santree 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -317,6 +317,15 @@ echo "$(date): $SANTREE_TICKET_ID waiting — $SANTREE_MESSAGE" >> /tmp/santree-
317
317
 
318
318
  Make it executable: `chmod +x .santree/hooks/on-waiting.sh`
319
319
 
320
+ ### Environment Variables
321
+
322
+ | Variable | Effect |
323
+ |---|---|
324
+ | `SANTREE_EDITOR` | Editor used by `e` (open in editor) actions in the dashboard. Defaults to `code`. Examples: `cursor`, `zed`, `code`, `nvim`. |
325
+ | `SANTREE_MULTIPLEXER` | Terminal multiplexer used by the dashboard and `worktree create --window`. One of `tmux`, `cmux`, `none`. If unset, auto-detects from `$TMUX` / `$CMUX_SURFACE_ID`. cmux is macOS-only and limited by [manaflow-ai/cmux#1472](https://github.com/manaflow-ai/cmux/issues/1472). |
326
+
327
+ Santree always launches Claude with `--permission-mode auto` (Claude Code's auto mode), or `plan` when invoked in plan mode. Worktree-scoped automation is the default — there is no opt-in flag.
328
+
320
329
  ---
321
330
 
322
331
  ## Command Options
@@ -13,6 +13,7 @@ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasI
13
13
  import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
15
  import { extractTicketId } from "../lib/git.js";
16
+ import { getMultiplexer } from "../lib/multiplexer/index.js";
16
17
  import { getPRTemplate } from "../lib/github.js";
17
18
  import { renderPrompt, renderDiff, renderTicket } from "../lib/prompts.js";
18
19
  import { getTicketContent } from "../lib/linear.js";
@@ -39,9 +40,6 @@ const CLAUDE_VERSION = (() => {
39
40
  }
40
41
  })();
41
42
  // ── Helpers ───────────────────────────────────────────────────────────
42
- function isInTmux() {
43
- return !!process.env.TMUX;
44
- }
45
43
  function slugify(title) {
46
44
  return title
47
45
  .toLowerCase()
@@ -137,12 +135,7 @@ function ensureAltScreen() {
137
135
  if (altScreenEntered)
138
136
  return;
139
137
  altScreenEntered = true;
140
- if (isInTmux()) {
141
- try {
142
- execSync('tmux rename-window "santree"', { stdio: "ignore" });
143
- }
144
- catch { }
145
- }
138
+ getMultiplexer().renameWindow("", "santree");
146
139
  process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
147
140
  process.stdout.write("\x1b[?25l"); // Hide cursor
148
141
  }
@@ -385,66 +378,81 @@ export default function Dashboard() {
385
378
  };
386
379
  }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
387
380
  // ── Actions ───────────────────────────────────────────────────────
388
- const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
381
+ const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
389
382
  const windowName = di.issue.identifier;
390
383
  const sessionId = di.worktree?.sessionId;
391
384
  const bin = resolveAgentBinary();
392
385
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
393
386
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
394
387
  const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
395
- try {
396
- // Switch to existing window if it exists
397
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
398
- const cmd = resumeCmd ?? workCmd;
399
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
400
- dispatch({
401
- type: "SET_ACTION_MESSAGE",
402
- message: resumeCmd
403
- ? `Resumed session in: ${windowName}`
404
- : `Launched ${mode} in: ${windowName}`,
405
- });
388
+ const cmd = resumeCmd ?? workCmd;
389
+ const mux = getMultiplexer();
390
+ const selected = await mux.selectWindow(windowName);
391
+ if (selected.ok) {
392
+ const sent = mux.sendCommand(windowName, cmd);
393
+ if (sent.ok) {
394
+ dispatch({
395
+ type: "SET_ACTION_MESSAGE",
396
+ message: resumeCmd
397
+ ? `Resumed session in: ${windowName}`
398
+ : `Launched ${mode} in: ${windowName}`,
399
+ });
400
+ }
401
+ else {
402
+ dispatch({
403
+ type: "SET_ACTION_MESSAGE",
404
+ message: `Focused ${windowName} — run \`${cmd}\` manually (${sent.reason})`,
405
+ });
406
+ }
406
407
  }
407
- catch {
408
- // Window doesn't exist — create it
409
- try {
410
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
411
- // Small delay so the new shell can start reading input before we send keys,
412
- // otherwise buffered keystrokes from the dashboard pane can leak in.
413
- execSync("sleep 0.1", { stdio: "ignore" });
414
- const cmd = resumeCmd ?? workCmd;
415
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, { stdio: "ignore" });
408
+ else {
409
+ const created = await mux.createWindow({
410
+ name: windowName,
411
+ cwd: worktreePath,
412
+ command: cmd,
413
+ });
414
+ if (created.ok) {
416
415
  dispatch({
417
416
  type: "SET_ACTION_MESSAGE",
418
417
  message: resumeCmd
419
418
  ? `Resumed session in new window: ${windowName}`
420
- : `Launched ${mode} in tmux window: ${windowName}`,
419
+ : `Launched ${mode} in ${mux.kind} window: ${windowName}`,
421
420
  });
422
421
  }
423
- catch {
424
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to create tmux window" });
422
+ else {
423
+ dispatch({
424
+ type: "SET_ACTION_MESSAGE",
425
+ message: `Failed to create ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
426
+ });
425
427
  }
426
428
  }
427
429
  // Delayed refresh to pick up session ID created by `st worktree work`
428
430
  setTimeout(() => refresh(), 3000);
429
431
  }, [refresh]);
430
- const launchAfterCreation = useCallback((mode, worktreePath, ticketId, contextFile) => {
431
- if (isInTmux()) {
432
+ const launchAfterCreation = useCallback(async (mode, worktreePath, ticketId, contextFile) => {
433
+ const mux = getMultiplexer();
434
+ if (mux.isActive()) {
432
435
  const windowName = ticketId;
433
436
  const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
434
437
  const workCmd = mode === "plan"
435
438
  ? `st worktree work --plan${contextArg}`
436
439
  : `st worktree work${contextArg}`;
437
- try {
438
- execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
439
- execSync("sleep 0.1", { stdio: "ignore" });
440
- execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
440
+ const created = await mux.createWindow({
441
+ name: windowName,
442
+ cwd: worktreePath,
443
+ command: workCmd,
444
+ });
445
+ if (created.ok) {
441
446
  dispatch({
442
447
  type: "SET_ACTION_MESSAGE",
443
448
  message: `Created worktree + launched ${mode} in: ${windowName}`,
444
449
  });
445
450
  }
446
- catch {
447
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree created, but tmux failed" });
451
+ else {
452
+ dispatch({
453
+ type: "SET_ACTION_MESSAGE",
454
+ message: `Worktree created, but ${mux.kind} failed${created.message ? `: ${created.message}` : ""}`,
455
+ });
448
456
  }
449
457
  setTimeout(() => refresh(), 3000);
450
458
  }
@@ -590,8 +598,8 @@ export default function Dashboard() {
590
598
  const contextFile = writeContextFile(customContext);
591
599
  if (di.worktree) {
592
600
  // Worktree exists — launch work
593
- if (isInTmux()) {
594
- launchWorkInTmux(di, mode, di.worktree.path, contextFile);
601
+ if (getMultiplexer().isActive()) {
602
+ void launchWorkInTmux(di, mode, di.worktree.path, contextFile);
595
603
  }
596
604
  else {
597
605
  leaveAltScreen();
@@ -1225,7 +1233,7 @@ export default function Dashboard() {
1225
1233
  dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened in ${editor}` });
1226
1234
  return;
1227
1235
  }
1228
- // AI Review in tmux
1236
+ // AI Review in multiplexer
1229
1237
  if (input === "r") {
1230
1238
  if (!ri.worktree) {
1231
1239
  dispatch({
@@ -1234,21 +1242,23 @@ export default function Dashboard() {
1234
1242
  });
1235
1243
  return;
1236
1244
  }
1237
- if (isInTmux()) {
1245
+ const mux = getMultiplexer();
1246
+ if (mux.isActive()) {
1238
1247
  const windowName = `review-${extractTicketId(ri.branch ?? "") ?? ri.pr.number}`;
1239
- try {
1240
- execSync(`tmux new-window -n "${windowName}" -c "${ri.worktree.path}"`, {
1241
- stdio: "ignore",
1248
+ const cwd = ri.worktree.path;
1249
+ void (async () => {
1250
+ const created = await mux.createWindow({
1251
+ name: windowName,
1252
+ cwd,
1253
+ command: "st pr review",
1242
1254
  });
1243
- execSync("sleep 0.1", { stdio: "ignore" });
1244
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, {
1245
- stdio: "ignore",
1255
+ dispatch({
1256
+ type: "SET_ACTION_MESSAGE",
1257
+ message: created.ok
1258
+ ? `Launched AI review in ${mux.kind}`
1259
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1246
1260
  });
1247
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched AI review in tmux" });
1248
- }
1249
- catch {
1250
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1251
- }
1261
+ })();
1252
1262
  }
1253
1263
  else {
1254
1264
  leaveAltScreen();
@@ -1330,30 +1340,30 @@ export default function Dashboard() {
1330
1340
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
1331
1341
  return;
1332
1342
  }
1333
- if (isInTmux()) {
1343
+ const mux = getMultiplexer();
1344
+ if (mux.isActive()) {
1334
1345
  const windowName = di.issue.identifier;
1335
1346
  const sessionId = di.worktree.sessionId;
1336
1347
  const bin = resolveAgentBinary();
1337
1348
  const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
1338
- try {
1339
- execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
1340
- }
1341
- catch {
1342
- // Window doesn't exist — create one and resume/launch
1343
- try {
1344
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1345
- stdio: "ignore",
1346
- });
1347
- execSync("sleep 0.1", { stdio: "ignore" });
1348
- const cmd = resumeCmd ?? "st worktree work";
1349
- execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, {
1350
- stdio: "ignore",
1349
+ const worktreePath = di.worktree.path;
1350
+ void (async () => {
1351
+ const selected = await mux.selectWindow(windowName);
1352
+ if (selected.ok)
1353
+ return;
1354
+ const cmd = resumeCmd ?? "st worktree work";
1355
+ const created = await mux.createWindow({
1356
+ name: windowName,
1357
+ cwd: worktreePath,
1358
+ command: cmd,
1359
+ });
1360
+ if (!created.ok) {
1361
+ dispatch({
1362
+ type: "SET_ACTION_MESSAGE",
1363
+ message: `Failed to switch ${mux.kind} window${created.message ? `: ${created.message}` : ""}`,
1351
1364
  });
1352
1365
  }
1353
- catch {
1354
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
1355
- }
1356
- }
1366
+ })();
1357
1367
  }
1358
1368
  else {
1359
1369
  leaveAltScreen();
@@ -1408,18 +1418,23 @@ export default function Dashboard() {
1408
1418
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
1409
1419
  return;
1410
1420
  }
1411
- if (isInTmux()) {
1421
+ const mux = getMultiplexer();
1422
+ if (mux.isActive()) {
1412
1423
  const windowName = `review-${di.issue.identifier}`;
1413
- try {
1414
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1415
- stdio: "ignore",
1424
+ const cwd = di.worktree.path;
1425
+ void (async () => {
1426
+ const created = await mux.createWindow({
1427
+ name: windowName,
1428
+ cwd,
1429
+ command: "st pr review",
1416
1430
  });
1417
- execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, { stdio: "ignore" });
1418
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched review in tmux" });
1419
- }
1420
- catch {
1421
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
1422
- }
1431
+ dispatch({
1432
+ type: "SET_ACTION_MESSAGE",
1433
+ message: created.ok
1434
+ ? `Launched review in ${mux.kind}`
1435
+ : `Failed to launch review${created.message ? `: ${created.message}` : ""}`,
1436
+ });
1437
+ })();
1423
1438
  }
1424
1439
  else {
1425
1440
  leaveAltScreen();
@@ -1467,18 +1482,23 @@ export default function Dashboard() {
1467
1482
  dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
1468
1483
  return;
1469
1484
  }
1470
- if (isInTmux()) {
1485
+ const mux = getMultiplexer();
1486
+ if (mux.isActive()) {
1471
1487
  const windowName = `fix-${di.issue.identifier}`;
1472
- try {
1473
- execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
1474
- stdio: "ignore",
1488
+ const cwd = di.worktree.path;
1489
+ void (async () => {
1490
+ const created = await mux.createWindow({
1491
+ name: windowName,
1492
+ cwd,
1493
+ command: "st pr fix",
1475
1494
  });
1476
- execSync(`tmux send-keys -t "${windowName}" "st pr fix" Enter`, { stdio: "ignore" });
1477
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched PR fix in tmux" });
1478
- }
1479
- catch {
1480
- dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch PR fix" });
1481
- }
1495
+ dispatch({
1496
+ type: "SET_ACTION_MESSAGE",
1497
+ message: created.ok
1498
+ ? `Launched PR fix in ${mux.kind}`
1499
+ : `Failed to launch PR fix${created.message ? `: ${created.message}` : ""}`,
1500
+ });
1501
+ })();
1482
1502
  }
1483
1503
  else {
1484
1504
  leaveAltScreen();
@@ -1510,7 +1530,7 @@ export default function Dashboard() {
1510
1530
  }
1511
1531
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
1512
1532
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
1513
- return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " newline ", _jsx(Text, { color: "cyan", bold: true, children: "\u2191\u2193\u2190\u2192" }), " move ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+V" }), " paste image ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " continue ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1533
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
1514
1534
  .split("\n")
1515
1535
  .slice(0, 12)
1516
1536
  .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] }))] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
@@ -11,6 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
12
  import { findMainRepoRoot, getSantreeDir, getInitScriptPath } from "../lib/git.js";
13
13
  import { getAuthStatus, getValidTokens } from "../lib/linear.js";
14
+ import { getMultiplexer } from "../lib/multiplexer/index.js";
14
15
  const execAsync = promisify(exec);
15
16
  export const description = "Check system requirements and integrations";
16
17
  /**
@@ -55,6 +56,70 @@ async function checkTool(name, description, required, versionCommand, hint) {
55
56
  path,
56
57
  };
57
58
  }
59
+ /**
60
+ * Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
61
+ * binary is reachable. Surfaces a hint when the configured multiplexer can't run.
62
+ */
63
+ async function checkMultiplexer() {
64
+ const mux = getMultiplexer();
65
+ const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
66
+ const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
67
+ if (mux.kind === "none") {
68
+ return {
69
+ name: "multiplexer",
70
+ description,
71
+ required: false,
72
+ installed: false,
73
+ hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
74
+ };
75
+ }
76
+ if (mux.kind === "tmux") {
77
+ const path = await getPath("tmux");
78
+ if (!path) {
79
+ return {
80
+ name: "tmux",
81
+ description,
82
+ required: false,
83
+ installed: false,
84
+ hint: "Install: brew install tmux",
85
+ };
86
+ }
87
+ const version = await tryExec("tmux -V");
88
+ return {
89
+ name: "tmux",
90
+ description,
91
+ required: false,
92
+ installed: true,
93
+ version: version || "unknown",
94
+ path,
95
+ };
96
+ }
97
+ // cmux
98
+ const path = await getPath("cmux");
99
+ if (!path) {
100
+ return {
101
+ name: "cmux",
102
+ description,
103
+ required: false,
104
+ installed: false,
105
+ hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
106
+ };
107
+ }
108
+ const version = await tryExec("cmux --version 2>/dev/null");
109
+ const ping = await tryExec("cmux ping 2>/dev/null");
110
+ const hint = !ping
111
+ ? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux. NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472)."
112
+ : "NOTE: cmux #1472 — programmatic workspaces may have dead PTYs (https://github.com/manaflow-ai/cmux/issues/1472).";
113
+ return {
114
+ name: "cmux",
115
+ description,
116
+ required: false,
117
+ installed: !!ping,
118
+ version: version || "unknown",
119
+ path,
120
+ hint,
121
+ };
122
+ }
58
123
  /**
59
124
  * Checks GitHub CLI auth status.
60
125
  * Uses `gh api user` which works across all gh versions.
@@ -399,7 +464,7 @@ export default function Doctor() {
399
464
  const results = await Promise.all([
400
465
  checkTool("git", "Version control", true, "git --version | head -1", "Install: brew install git"),
401
466
  checkGhAuth(),
402
- checkTool("tmux", "Terminal multiplexer", false, "tmux -V", "Install: brew install tmux"),
467
+ checkMultiplexer(),
403
468
  checkTool("claude", "Claude Code CLI", true, "claude --version 2>/dev/null | head -1", "Install: npm install -g @anthropic-ai/claude-code"),
404
469
  ]);
405
470
  // Check for either code or cursor (only need one)
@@ -0,0 +1,13 @@
1
+ import { z } from "zod/v4";
2
+ export declare const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
3
+ export declare const options: z.ZodObject<{
4
+ initial: z.ZodOptional<z.ZodString>;
5
+ from: z.ZodOptional<z.ZodString>;
6
+ ext: z.ZodDefault<z.ZodString>;
7
+ editor: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>;
9
+ type Props = {
10
+ options: z.infer<typeof options>;
11
+ };
12
+ export default function TextEditor({ options: opts }: Props): null;
13
+ export {};
@@ -0,0 +1,118 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useApp } from "ink";
3
+ import { option } from "pastel";
4
+ import { z } from "zod/v4";
5
+ import { spawnSync } from "node:child_process";
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ export const description = "Open $EDITOR on a temp file, then print the path on stdout (compose with --context-file).";
10
+ export const options = z.object({
11
+ initial: z
12
+ .string()
13
+ .optional()
14
+ .describe(option({ description: "Pre-fill the editor buffer with this text" })),
15
+ from: z
16
+ .string()
17
+ .optional()
18
+ .describe(option({ description: "Pre-fill the editor buffer with the contents of this file" })),
19
+ ext: z
20
+ .string()
21
+ .default("md")
22
+ .describe(option({ description: "Temp file extension (default: md)" })),
23
+ editor: z
24
+ .string()
25
+ .optional()
26
+ .describe(option({ description: "Override the editor command (default: $VISUAL || $EDITOR || vim)" })),
27
+ });
28
+ function resolveEditor(override) {
29
+ const raw = override ?? process.env["VISUAL"] ?? process.env["EDITOR"] ?? "vim";
30
+ const parts = raw.split(/\s+/).filter(Boolean);
31
+ const cmd = parts[0] ?? "vim";
32
+ return { cmd, args: parts.slice(1) };
33
+ }
34
+ // Render null and write all UI feedback to stderr so stdout stays clean for
35
+ // shell capture: `file=$(st helpers text-editor) && st worktree work --context-file "$file"`.
36
+ export default function TextEditor({ options: opts }) {
37
+ const { exit } = useApp();
38
+ const hasRun = useRef(false);
39
+ useEffect(() => {
40
+ if (hasRun.current)
41
+ return;
42
+ hasRun.current = true;
43
+ const ext = opts.ext.replace(/^\./, "");
44
+ const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext}`);
45
+ const seed = (() => {
46
+ if (opts.from) {
47
+ try {
48
+ return fs.readFileSync(opts.from, "utf-8");
49
+ }
50
+ catch {
51
+ return opts.initial ?? "";
52
+ }
53
+ }
54
+ return opts.initial ?? "";
55
+ })();
56
+ try {
57
+ fs.writeFileSync(filePath, seed);
58
+ }
59
+ catch (err) {
60
+ process.stderr.write(`Failed to create temp file: ${err.message}\n`);
61
+ process.exitCode = 1;
62
+ exit();
63
+ return;
64
+ }
65
+ // Ink put stdin in raw mode on mount; release it for the editor.
66
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
67
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
68
+ try {
69
+ process.stdin.setRawMode(false);
70
+ }
71
+ catch { }
72
+ }
73
+ const { cmd, args } = resolveEditor(opts.editor);
74
+ const result = spawnSync(cmd, [...args, filePath], { stdio: "inherit" });
75
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
76
+ try {
77
+ process.stdin.setRawMode(wasRaw);
78
+ }
79
+ catch { }
80
+ }
81
+ if (result.error || result.status !== 0) {
82
+ process.stderr.write(result.error
83
+ ? `Failed to launch editor '${cmd}': ${result.error.message}\n`
84
+ : `Editor '${cmd}' exited with status ${result.status}\n`);
85
+ try {
86
+ fs.unlinkSync(filePath);
87
+ }
88
+ catch { }
89
+ process.exitCode = 1;
90
+ exit();
91
+ return;
92
+ }
93
+ let content = "";
94
+ try {
95
+ content = fs.readFileSync(filePath, "utf-8");
96
+ }
97
+ catch (err) {
98
+ process.stderr.write(`Failed to read temp file: ${err.message}\n`);
99
+ process.exitCode = 1;
100
+ exit();
101
+ return;
102
+ }
103
+ // Empty buffer => treat as cancel (matches `git commit` behavior)
104
+ if (content.trim().length === 0) {
105
+ try {
106
+ fs.unlinkSync(filePath);
107
+ }
108
+ catch { }
109
+ process.stderr.write("Cancelled (empty buffer)\n");
110
+ process.exitCode = 1;
111
+ exit();
112
+ return;
113
+ }
114
+ process.stdout.write(`${filePath}\n`);
115
+ exit();
116
+ }, [opts, exit]);
117
+ return null;
118
+ }
@@ -5,6 +5,7 @@ export declare const options: z.ZodObject<{
5
5
  work: z.ZodOptional<z.ZodBoolean>;
6
6
  plan: z.ZodOptional<z.ZodBoolean>;
7
7
  "no-pull": z.ZodOptional<z.ZodBoolean>;
8
+ window: z.ZodOptional<z.ZodBoolean>;
8
9
  tmux: z.ZodOptional<z.ZodBoolean>;
9
10
  name: z.ZodOptional<z.ZodString>;
10
11
  }, z.core.$strip>;